1
|
|
|
/*! |
2
|
|
|
* Copyright (c) 2022 Pedro José Batista, licensed under the MIT License. |
3
|
|
|
* See the LICENSE.md file in the project root for more information. |
4
|
|
|
*/ |
5
|
1 |
|
import Decimal from "decimal.js"; |
6
|
|
|
import type BaseFormatOptions from "./baseOptions"; |
7
|
|
|
import type CompactDisplay from "./compactDisplay"; |
8
|
1 |
|
import { BIGINT_MODIFIERS, ECMA_LIMIT, PLAIN_MODIFIERS, SUPPORTED_LOCALES } from "./constants"; |
9
|
|
|
import type Currency from "./currency"; |
10
|
|
|
import type CurrencyDisplay from "./currencyDisplay"; |
11
|
|
|
import type CurrencySign from "./currencySign"; |
12
|
|
|
import type Locale from "./locale"; |
13
|
|
|
import type LocaleMatcher from "./localeMatcher"; |
14
|
|
|
import type Notation from "./notation"; |
15
|
|
|
import type NumberingSystem from "./numberingSystem"; |
16
|
|
|
import type FormatOptions from "./options"; |
17
|
1 |
|
import { extend, resolve, toEcma, validate } from "./options"; |
18
|
|
|
import type FormatPart from "./part"; |
19
|
1 |
|
import { exponents, fractions, integerGroups, integers, PartValue } from "./part"; |
20
|
|
|
import type FormatPartTypes from "./partTypes"; |
21
|
|
|
import type ResolvedFormatOptions from "./resolvedOptions"; |
22
|
|
|
import type SignDisplay from "./signDisplay"; |
23
|
|
|
import type Style from "./style"; |
24
|
|
|
import type TrailingZeroDisplay from "./trailingZeroDisplay"; |
25
|
|
|
import type Unit from "./unit"; |
26
|
|
|
import type UnitDisplay from "./unitDisplay"; |
27
|
|
|
import type UseGrouping from "./useGrouping"; |
28
|
|
|
|
29
|
1 |
|
const concatenate = <T extends PartValue>(filter: T[] | ((p: T) => boolean), parts: T[] = []) => { |
30
|
21693 |
|
if (typeof filter === "function") { |
31
|
16636 |
|
parts = parts.filter(filter); |
32
|
|
|
} else { |
33
|
5057 |
|
parts = filter; |
34
|
|
|
} |
35
|
|
|
|
36
|
30111 |
|
return parts.map(p => p.value).join(""); |
37
|
|
|
}; |
38
|
|
|
|
39
|
40 |
|
const pow10 = (exponent: Decimal.Value) => Decimal.pow(10, exponent); |
40
|
|
|
|
41
|
|
|
/** |
42
|
|
|
* The `Decimal.Format` object enables language-sensitive decimal number formatting. It is entirely based on |
43
|
|
|
* `Intl.NumberFormat`, with the options of the latter being 100% compatible with it. |
44
|
|
|
* |
45
|
|
|
* This class, however, extend the numeric digits constraints of `Intl.NumberFormat` from 21 to 1000000000 in |
46
|
|
|
* order to fully take advantage of the arbitrary-precision of `decimal.js`. |
47
|
|
|
* |
48
|
|
|
* @template N Numeric notation of formatting. |
49
|
|
|
* @template S Numeric style of formatting. |
50
|
|
|
*/ |
51
|
1 |
|
export class DecimalFormat<N extends Notation, S extends Style> { |
52
|
1 |
|
static readonly [Symbol.toPrimitive] = DecimalFormat; |
53
|
254 |
|
readonly [Symbol.toStringTag] = "Decimal.Format"; |
54
|
|
|
|
55
|
|
|
/** |
56
|
|
|
* Formats a number according to the locale and formatting options of this {@link DecimalFormat} object. |
57
|
|
|
* |
58
|
|
|
* @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format. |
59
|
|
|
* @returns Formatted localized string. |
60
|
|
|
*/ |
61
|
|
|
readonly format: (value: Decimal.Value) => string; |
62
|
|
|
|
63
|
|
|
/** |
64
|
|
|
* Allows locale-aware formatting of strings produced by `Decimal.Format` formatters. |
65
|
|
|
* |
66
|
|
|
* @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format. |
67
|
|
|
* @returns An array of objects containing the formatted number in parts. |
68
|
|
|
*/ |
69
|
|
|
readonly formatToParts: (value: Decimal.Value) => FormatPart[]; |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* Returns a new object with properties reflecting the locale and number formatting options computed during |
73
|
|
|
* initialization of this {@link Decimal.Format} object. |
74
|
|
|
* |
75
|
|
|
* @returns A new object with properties reflecting the locale and number formatting options computed |
76
|
|
|
* during the initialization of this object. |
77
|
|
|
*/ |
78
|
|
|
readonly resolvedOptions: () => ResolvedFormatOptions<N, S>; |
79
|
|
|
|
80
|
|
|
/** |
81
|
|
|
* Creates a new instance of the `Decimal.Format` object. |
82
|
|
|
* |
83
|
|
|
* @param locales A string with a [BCP 47](https://www.rfc-editor.org/info/bcp47) language tag, or an array |
84
|
|
|
* of such strings. |
85
|
|
|
* |
86
|
|
|
* For the general form and interpretation of this parameter, see the [Intl page on |
87
|
|
|
* MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). |
88
|
|
|
* @param options Object used to configure the behavior of the string localization. |
89
|
|
|
* @throws `RangeError` when an invalid option is given. |
90
|
|
|
*/ |
91
|
|
|
constructor(locales?: Locale | Locale[], options?: FormatOptions<N, S>) { |
92
|
254 |
|
options ??= {}; |
93
|
|
|
|
94
|
|
|
// 1. Check if options do not extrapolate the limits of decimal.js |
95
|
254 |
|
const valid = validate(options); |
96
|
|
|
|
97
|
254 |
|
if (valid !== true) { |
98
|
|
|
// -> it will either be exactly true or contain an array with all faulty properties: |
99
|
5 |
|
throw new RangeError(`${valid.join()} value${valid.length === 1 ? " is" : "s are"} out of range."`); |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
// 2. Create a baseline native formatter native |
103
|
249 |
|
const ecmaOptions = toEcma(options); |
104
|
249 |
|
const ecmaFormat = new Intl.NumberFormat(locales, ecmaOptions); |
105
|
|
|
|
106
|
|
|
// 3. Resolve this object's options, using the native resolution as a baseline |
107
|
249 |
|
const resolved = resolve(options, ecmaFormat.resolvedOptions()); |
108
|
249 |
|
const { minimumIntegerDigits: minID, notation, rounding, style } = resolved; |
109
|
|
|
|
110
|
|
|
// 4. Create two auxiliary formatters: |
111
|
|
|
// One for the integer part, which can have up to a billion minimum digits... |
112
|
249 |
|
const bigintOptions = extend(ecmaOptions, BIGINT_MODIFIERS); |
113
|
249 |
|
const bigintFormat = new Intl.NumberFormat(locales, bigintOptions); |
114
|
|
|
|
115
|
|
|
// ...and another for a plain, localized reference, used for decimals and constants |
116
|
249 |
|
const plainOptions = extend(bigintOptions, PLAIN_MODIFIERS); |
117
|
249 |
|
const plainFormat = new Intl.NumberFormat(locales, plainOptions); |
118
|
|
|
|
119
|
|
|
// 5. Localized numeric constants |
120
|
249 |
|
const numbers = Array(10) |
121
|
|
|
.fill(null) |
122
|
2490 |
|
.map((_, index) => plainFormat.format(index)); |
123
|
249 |
|
const numberMatch = new RegExp("[" + numbers.join("") + "]", "g"); |
124
|
249 |
|
const minusSign = /−/gu; |
125
|
|
|
|
126
|
|
|
// 5.1. Localized zero and one used in substitutions |
127
|
249 |
|
const [zero, one] = numbers; |
128
|
|
|
|
129
|
|
|
// 5.2. Helper functions |
130
|
249 |
|
const indexOfValue = (value: string) => numbers.indexOf(value).toString(); |
131
|
249 |
|
const convert = (text: string) => text.replaceAll(numberMatch, indexOfValue).replaceAll(minusSign, "-"); |
132
|
249 |
|
const zeroFill = (size: number) => Array(size).fill(zero).join(""); |
133
|
249 |
|
const zeroTrim = (text: string, mode: "both" | "left" | "right" = "left", max: number | boolean = false) => { |
134
|
4159 |
|
let result = text; |
135
|
4159 |
|
let count = 0; |
136
|
|
|
|
137
|
4159 |
|
if (mode === "both" || mode === "left") |
138
|
4159 |
|
while (result[0] === zero && result.length > 1 && (max === false || count < max)) { |
139
|
335 |
|
result = result.slice(1); |
140
|
335 |
|
count++; |
141
|
|
|
} |
142
|
|
|
|
143
|
4159 |
|
if (mode === "both" || mode === "right") |
144
|
4 |
|
while (result[result.length - 1] === zero && result.length > 1 && (max === false || count < max)) { |
145
|
|
|
result = result.slice(0, -1); |
146
|
|
|
count++; |
147
|
|
|
} |
148
|
|
|
|
149
|
4159 |
|
return result; |
150
|
|
|
}; |
151
|
|
|
|
152
|
|
|
// #region Step 6. Main format method - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
153
|
249 |
|
const _formatToParts = (value: Decimal.Value) => { |
154
|
5079 |
|
value = new Decimal(value); |
155
|
5079 |
|
const sign = Decimal.sign(value); |
156
|
|
|
|
157
|
|
|
// 6.1. Create a baseline part array |
158
|
5079 |
|
const ecmaParts = ecmaFormat.formatToParts(value.toNumber()); |
159
|
|
|
|
160
|
|
|
// -> if the value is non-numeric or an infinity, the baseline is good enough |
161
|
5079 |
|
if ((value.isFinite && !value.isFinite()) || (value.isNaN && value.isNaN())) { |
162
|
920 |
|
return ecmaParts; |
163
|
|
|
} |
164
|
|
|
|
165
|
|
|
// 6.2. Splitting the parts for easier assembly |
166
|
4159 |
|
const ecmaExponentValue = concatenate(exponents, ecmaParts) || "0"; |
167
|
4159 |
|
const ecmaIntegerParts = ecmaParts.filter(integerGroups); |
168
|
4159 |
|
const ecmaIntegerTrimmed = zeroTrim(concatenate(integers, ecmaIntegerParts)); |
169
|
4159 |
|
const ecmaIntegerDigits = concatenate(integers, ecmaIntegerParts).length; |
170
|
4159 |
|
const ecmaIntegerTrimmedDigits = ecmaIntegerTrimmed.length; |
171
|
4159 |
|
const ecmaFractionValue = concatenate(fractions, ecmaParts); |
172
|
4159 |
|
const ecmaFractionDigits = ecmaFractionValue.length; |
173
|
|
|
|
174
|
|
|
// 6.3. Shifting exponents according to notation/style |
175
|
|
|
|
176
|
|
|
// 6.3.1. Compact notation: calculate the shift in integer digits, and therefore exponent |
177
|
4159 |
|
if (notation === "compact" && !value.eq(0)) { |
178
|
|
|
const baseInteger = value.abs().trunc().toFixed(); |
179
|
|
|
const baseIntegerDigits = baseInteger.length; |
180
|
|
|
const correctionDigits = baseIntegerDigits - ecmaIntegerTrimmedDigits; |
181
|
|
|
|
182
|
2 |
|
if (correctionDigits > 0) { |
183
|
|
|
value = value.mul(pow10(-correctionDigits)); |
184
|
|
|
} |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
// 6.3.2. Engr./Scientific notations: evaluate the exponent from the text |
188
|
4159 |
|
if ((notation === "engineering" || notation === "scientific") && ecmaExponentValue !== zero) { |
189
|
4 |
|
const exponential = new Decimal(convert(ecmaExponentValue)); |
190
|
4 |
|
value = value.mul(pow10(exponential.mul(-1))).abs().mul(sign); // prettier-ignore |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
// 6.3.3. Percent style: shift the value accordingly (non numeric parts will remain the same) |
194
|
4159 |
|
if (style === "percent") value = value.mul(100); |
195
|
|
|
|
196
|
|
|
// 6.4. Parsing the information about the numeric parts |
197
|
4159 |
|
const integer = value.abs().trunc().mul(sign); |
198
|
4159 |
|
const fraction = value.sub(integer).abs(); |
199
|
4159 |
|
const integerDigits = !value.eq(0) && integer.eq(0) ? 0 : value.abs().trunc().toFixed().length; |
200
|
4159 |
|
const fractionDigits = value.dp(); |
201
|
4159 |
|
const maxSD = resolved.maximumSignificantDigits ?? integerDigits + fractionDigits; |
202
|
4159 |
|
const maxFD = resolved.maximumFractionDigits ?? fractionDigits; |
203
|
4159 |
|
const minSD = resolved.minimumSignificantDigits ?? resolved.minimumFractionDigits! + minID; |
204
|
4159 |
|
const minFD = resolved.minimumFractionDigits ?? minSD - minID; |
205
|
|
|
|
206
|
|
|
// 6.5. Check for the possibility of the native formatter to have accomplished the desired output |
207
|
4159 |
|
const integerCheck = !ecmaIntegerParts.length || (minID <= ECMA_LIMIT && ecmaIntegerDigits >= minID); |
208
|
|
|
const fractionCheck = |
209
|
4159 |
|
ecmaFractionDigits >= fractionDigits && minFD < ECMA_LIMIT && ecmaFractionDigits >= minFD; |
210
|
|
|
|
211
|
|
|
// -> if the native formatter is good enough for our decimal value, leave it as-is |
212
|
4159 |
|
if (integerCheck && fractionCheck) { |
213
|
3666 |
|
return ecmaParts as FormatPart[]; |
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
// 6.6. Create the integer value |
217
|
493 |
|
const integerParts = (() => { |
218
|
493 |
|
if (integerCheck) return ecmaIntegerParts; |
219
|
|
|
|
220
|
|
|
// Expanding the integer part |
221
|
19 |
|
const targetDigits = Math.max(integerDigits, minID); |
222
|
|
|
|
223
|
|
|
// Creates a base 10 power of the target digits |
224
|
19 |
|
const bigint = BigInt(pow10(targetDigits - 1).toFixed()); |
225
|
|
|
|
226
|
|
|
// Format using the bigint formatter and cut it before joining with the ECMA parts |
227
|
19 |
|
const bigintIntegerParts = bigintFormat.formatToParts(bigint).filter(integerGroups); |
228
|
|
|
|
229
|
|
|
// We need to replace the first 'one' (from the base 10 power) with a 'zero' |
230
|
19 |
|
bigintIntegerParts[0].value = bigintIntegerParts[0].value.replace(new RegExp(one), zero); |
231
|
|
|
|
232
|
|
|
// Merge the first part with the bigint part |
233
|
19 |
|
ecmaIntegerParts[0].value = |
234
|
|
|
bigintIntegerParts[ecmaIntegerParts.length - 1].value.slice(0, -ecmaIntegerParts[0].value.length) + |
235
|
|
|
ecmaIntegerParts[0].value; |
236
|
|
|
|
237
|
19 |
|
return [...bigintIntegerParts.slice(0, -ecmaIntegerParts.length), ...ecmaIntegerParts]; |
238
|
|
|
})(); |
239
|
|
|
|
240
|
|
|
// 6.7. Create the fraction value |
241
|
493 |
|
const fractionValue = (() => { |
242
|
493 |
|
if (fractionCheck) return ecmaFractionValue; |
243
|
|
|
|
244
|
|
|
// Simpler formatting if there is actually no fraction |
245
|
492 |
|
if (fraction.eq(0)) { |
246
|
17 |
|
return plainFormat.format(BigInt(pow10(minFD).toFixed())).slice(1); |
247
|
|
|
} |
248
|
|
|
|
249
|
|
|
// There are more digits in the number than in the formatting |
250
|
475 |
|
const value = fraction |
251
|
|
|
.toFixed() |
252
|
|
|
.slice(2) |
253
|
|
|
.split("") |
254
|
3163 |
|
.map(v => numbers[Number(v)]) |
255
|
|
|
.join(""); |
256
|
|
|
|
257
|
475 |
|
if (value.length > maxFD) { |
258
|
|
|
return fraction |
259
|
|
|
.toDP(maxFD, rounding) |
260
|
|
|
.mul(pow10(maxFD)) |
261
|
|
|
.toFixed() |
262
|
|
|
.split("") |
263
|
|
|
.map(v => numbers[Number(v)]) |
264
|
|
|
.join(""); |
265
|
|
|
} |
266
|
|
|
|
267
|
475 |
|
if (value.length < minFD) { |
268
|
15 |
|
return value + zeroFill(minFD - value.length); |
269
|
|
|
} |
270
|
|
|
|
271
|
460 |
|
return value; |
272
|
|
|
})(); |
273
|
|
|
|
274
|
|
|
// 6.8. Parsing the numeric fragments in a unified part array |
275
|
493 |
|
const result: FormatPart[] = []; |
276
|
493 |
|
let integerDone = false; |
277
|
493 |
|
let fractionDone = false; |
278
|
|
|
|
279
|
493 |
|
while (ecmaParts.length) { |
280
|
3915 |
|
const { type, value } = ecmaParts.shift()!; |
281
|
|
|
|
282
|
3915 |
|
if (type === "integer" || type === "group") { |
283
|
2631 |
|
if (!integerDone) { |
284
|
493 |
|
integerDone = true; |
285
|
493 |
|
result.push(...integerParts); |
286
|
|
|
} |
287
|
2631 |
|
continue; |
288
|
|
|
} |
289
|
|
|
|
290
|
1284 |
|
if (type === "fraction") { |
291
|
492 |
|
if (!fractionDone) { |
292
|
492 |
|
fractionDone = true; |
293
|
492 |
|
result.push({ type, value: fractionValue }); |
294
|
|
|
} |
295
|
492 |
|
continue; |
296
|
|
|
} |
297
|
|
|
|
298
|
792 |
|
result.push({ type, value }); |
299
|
|
|
} |
300
|
493 |
|
return result; |
301
|
|
|
}; |
302
|
|
|
//#endregion |
303
|
|
|
|
304
|
5057 |
|
this.format = value => concatenate(_formatToParts(value)); |
305
|
249 |
|
this.formatToParts = value => _formatToParts(value); |
306
|
249 |
|
this.resolvedOptions = () => ({ ...resolved }); |
307
|
|
|
} |
308
|
|
|
|
309
|
|
|
/** |
310
|
|
|
* Returns an array containing the default locales available to the environment, based on a default |
311
|
|
|
* dictionary of locales and regions. |
312
|
|
|
* |
313
|
|
|
* This method is non-standard method that is not available on `Intl` formatters. |
314
|
|
|
* |
315
|
|
|
* @returns Array of strings with the available locales. |
316
|
|
|
*/ |
317
|
|
|
static supportedLocales() { |
318
|
2 |
|
return SUPPORTED_LOCALES; |
319
|
|
|
} |
320
|
|
|
|
321
|
|
|
/** |
322
|
|
|
* Returns an array containing those of the provided locales that are supported without having to fall back |
323
|
|
|
* to the runtime's default locale. |
324
|
|
|
* |
325
|
|
|
* @template TNotation Numeric notation of formatting. |
326
|
|
|
* @template TStyle Numeric style of formatting. |
327
|
|
|
* @param locales A string with a BCP 47 language tag, or an array of such strings. For the general form |
328
|
|
|
* and interpretation of the locales argument, see the [Intl page on |
329
|
|
|
* MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). |
330
|
|
|
* @param options Object used to configure the behavior of the string localization. |
331
|
|
|
* @returns Array of strings with the available locales. |
332
|
|
|
*/ |
333
|
|
|
static supportedLocalesOf<TNotation extends Notation = "standard", TStyle extends Style = "decimal">( |
334
|
|
|
locales: string | string[], |
335
|
|
|
options?: FormatOptions<TNotation, TStyle>, |
336
|
|
|
) { |
337
|
2 |
|
return Intl.NumberFormat.supportedLocalesOf(locales, options ? toEcma(options) : undefined) as Locale[]; |
338
|
|
|
} |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
// eslint-disable-next-line @typescript-eslint/no-namespace |
342
|
|
|
export declare namespace DecimalFormat { |
343
|
|
|
export type { |
344
|
|
|
BaseFormatOptions, |
345
|
|
|
CompactDisplay, |
346
|
|
|
Currency, |
347
|
|
|
CurrencyDisplay, |
348
|
|
|
CurrencySign, |
349
|
|
|
Locale, |
350
|
|
|
LocaleMatcher, |
351
|
|
|
Notation, |
352
|
|
|
NumberingSystem, |
353
|
|
|
FormatOptions, |
354
|
|
|
FormatPart, |
355
|
|
|
FormatPartTypes, |
356
|
|
|
ResolvedFormatOptions, |
357
|
|
|
SignDisplay, |
358
|
|
|
Style, |
359
|
|
|
TrailingZeroDisplay, |
360
|
|
|
Unit, |
361
|
|
|
UnitDisplay, |
362
|
|
|
UseGrouping, |
363
|
|
|
}; |
364
|
|
|
} |
365
|
|
|
|
366
|
|
|
export default DecimalFormat; |
367
|
|
|
|